From f4bf52e84388ad0d1d270aac9092b49f05bfc988 Mon Sep 17 00:00:00 2001 From: Aaron Schulz Date: Thu, 21 Jul 2016 22:15:30 -0700 Subject: [PATCH] Automatically detect READ_ONLY mode for MySQL/MariaDB This avoids having users think they can make edits when an exception will just be thrown when they try to save. Likewise for other write actions. Bug: T24923 Change-Id: I49c4057b672875ec6f34681a5668a509cec05677 --- includes/db/DBConnRef.php | 4 +++ includes/db/Database.php | 4 +++ includes/db/DatabaseMysqlBase.php | 7 +++++ includes/db/IDatabase.php | 6 ++++ includes/db/loadbalancer/LoadBalancer.php | 36 +++++++++++++++++++++-- 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/includes/db/DBConnRef.php b/includes/db/DBConnRef.php index 1893c73583..53862b96da 100644 --- a/includes/db/DBConnRef.php +++ b/includes/db/DBConnRef.php @@ -417,6 +417,10 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } + public function serverIsReadOnly() { + return $this->__call( __FUNCTION__, func_get_args() ); + } + public function onTransactionResolution( callable $callback ) { return $this->__call( __FUNCTION__, func_get_args() ); } diff --git a/includes/db/Database.php b/includes/db/Database.php index 2e3e22552b..3dc6e9213b 100644 --- a/includes/db/Database.php +++ b/includes/db/Database.php @@ -2456,6 +2456,10 @@ abstract class DatabaseBase implements IDatabase { return false; } + public function serverIsReadOnly() { + return false; + } + final public function onTransactionResolution( callable $callback ) { if ( !$this->mTrxLevel ) { throw new DBUnexpectedError( $this, "No transaction is active." ); diff --git a/includes/db/DatabaseMysqlBase.php b/includes/db/DatabaseMysqlBase.php index 02a8d308c7..a6f8c311ee 100644 --- a/includes/db/DatabaseMysqlBase.php +++ b/includes/db/DatabaseMysqlBase.php @@ -885,6 +885,13 @@ abstract class DatabaseMysqlBase extends Database { } } + public function serverIsReadOnly() { + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'read_only'", __METHOD__ ); + $row = $this->fetchObject( $res ); + + return $row ? ( strtolower( $row->Value ) === 'on' ) : false; + } + /** * @param string $index * @return string diff --git a/includes/db/IDatabase.php b/includes/db/IDatabase.php index aa2a9807a6..41b131f40b 100644 --- a/includes/db/IDatabase.php +++ b/includes/db/IDatabase.php @@ -1220,6 +1220,12 @@ interface IDatabase { */ public function getMasterPos(); + /** + * @return bool Whether the DB is marked as read-only server-side + * @since 1.28 + */ + public function serverIsReadOnly(); + /** * Run a callback as soon as the current transaction commits or rolls back. * An error is thrown if no transaction is pending. Queries in the function will run in diff --git a/includes/db/loadbalancer/LoadBalancer.php b/includes/db/loadbalancer/LoadBalancer.php index a67eac1e79..2543958213 100644 --- a/includes/db/loadbalancer/LoadBalancer.php +++ b/includes/db/loadbalancer/LoadBalancer.php @@ -49,6 +49,8 @@ class LoadBalancer { private $mLoadMonitor; /** @var BagOStuff */ private $srvCache; + /** @var WANObjectCache */ + private $wanCache; /** @var bool|DatabaseBase Database connection that caused a problem */ private $mErrorConnection; @@ -76,6 +78,8 @@ class LoadBalancer { const MAX_LAG = 10; /** @var integer Max time to wait for a slave to catch up (e.g. ChronologyProtector) */ const POS_WAIT_TIMEOUT = 10; + /** @var integer Seconds to cache master server read-only status */ + const TTL_CACHE_READONLY = 5; /** * @var boolean @@ -135,6 +139,7 @@ class LoadBalancer { } $this->srvCache = ObjectCache::getLocalServerInstance(); + $this->wanCache = ObjectCache::getMainWANInstance(); if ( isset( $params['trxProfiler'] ) ) { $this->trxProfiler = $params['trxProfiler']; @@ -578,7 +583,7 @@ class LoadBalancer { if ( $masterOnly ) { # Make master-requested DB handles inherit any read-only mode setting - $conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $wiki ) ); + $conn->setLBInfo( 'readOnlyReason', $this->getReadOnlyReason( $wiki, $conn ) ); } return $conn; @@ -1274,10 +1279,11 @@ class LoadBalancer { /** * @note This method may trigger a DB connection if not yet done * @param string|bool $wiki Wiki ID, or false for the current wiki + * @param DatabaseBase|null DB master connection; used to avoid loops [optional] * @return string|bool Reason the master is read-only or false if it is not * @since 1.27 */ - public function getReadOnlyReason( $wiki = false ) { + public function getReadOnlyReason( $wiki = false, DatabaseBase $conn = null ) { if ( $this->readOnlyReason !== false ) { return $this->readOnlyReason; } elseif ( $this->getLaggedSlaveMode( $wiki ) ) { @@ -1288,11 +1294,37 @@ class LoadBalancer { return 'The database has been automatically locked ' . 'while the slave database servers catch up to the master.'; } + } elseif ( $this->masterRunningReadOnly( $wiki, $conn ) ) { + return 'The database master is running in read-only mode.'; } return false; } + /** + * @param string $wiki Wiki ID, or false for the current wiki + * @param DatabaseBase|null DB master connectionl used to avoid loops [optional] + * @return bool + */ + private function masterRunningReadOnly( $wiki, DatabaseBase $conn = null ) { + $cache = $this->wanCache; + $masterServer = $this->getServerName( $this->getWriterIndex() ); + + return (bool)$cache->getWithSetCallback( + $cache->makeGlobalKey( __CLASS__, 'server-read-only', $masterServer ), + self::TTL_CACHE_READONLY, + function () use ( $wiki, $conn ) { + try { + $dbw = $conn ?: $this->getConnection( DB_MASTER, [], $wiki ); + return (int)$dbw->serverIsReadOnly(); + } catch ( DBError $e ) { + return 0; + } + }, + [ 'pcTTL' => $cache::TTL_PROC_LONG, 'busyValue' => 0 ] + ); + } + /** * Disables/enables lag checks * @param null|bool $mode -- 2.20.1